查看原文
其他

【第2836期】JSONQL 低代码数据模型引擎的设计与实现

吴多益 前端早读课 2023-01-13

前言

介绍 JSONQL 语言,有点长,可以了解。今日前端早读课文章由百度 @吴多益授权分享。

正文从这开始~~

在低代码产品中,后端最重要的功能是数据模型,它的能力决定了低代码平台可以开发应用的上限,之前在介绍【第2501期】从实现原理看低代码时列举过几种实现方式,其中只有第一个动态 ORM 方案支持对接已有数据库,这个方案比其它方案的灵活性和性能都更好,能制作的应用上限最高,因此爱速搭里首先实现的就是这个方案。

本文将介绍爱速搭中数据模型的实现细节,以及其中最重要的 JSONQL 语言。

在介绍 JSONQL 前,先回顾一下在专业开发中如何进行数据查询,目前常见有三种方案:

  • 裸写 SQL 方案,包括 MyBatis 这种映射方案,这个方案灵活性强,可以使用所有 SQL 功能,缺点是无法适配多种数据库,对于复杂的关联查询需要自己 JOIN 表,由于上手门槛低,是目前国内开发者主要使用的方案。

  • Query Builder 方案,它将 SQL 做了一层封装,开发者可以通过代码来构造 SQL,比裸写 SQL 多了类型检查,开发时不容易写错,同时这个方案很适合既想支持多种数据库,又想接近 SQL 开发体验的开发者。

  • ORM 方案,比如 JPA/Hibernate/ActiveRecord,主要特点是几乎屏蔽了 SQL,这也导致了上手成本高,初次接触需要学习大量概念,优点是上手后开发效率很高,关联查询写起来也简单,还能适配多种数据库,但这个方案的灵活性比 SQL 弱,有些特殊的数据库方言无法实现,而且屏蔽了 SQL 导致开发者不会怎么关注最终生成 SQL,很容易生成低效查询,比如查询所有列及 N+1 次查询问题。

有没有一种方案,可以同时有这些方案的优点,既能做到和 SQL 一样的灵活性,又能跨数据库,又能支持简易关联查询,又能解决查询无用字段及 N+1 问题呢?

这就是本文将介绍的新方案 JSONQL,它是爱速搭数据模型的前后端交互语言。

爱速搭中的数据模型

可能你还没用过爱速搭,所以在介绍 JSONQL 之前,先简单介绍一下爱速搭中数据模型的使用流程,主要分 3 步:

  • 连接数据源

  • 导入表结构,建立关联关系

  • 设计前端界面

第 1 步是连接数据源,这个比较简单就是填入数据库地址和账号密码。

第 2 步是导入表结构,比如原始数据库里的表结构如下

表结构

导入之后还能添加关联关系,这里添加了两个关系,一个是 user 下有多个 blog 的一对多关系,关系名是 blogs,另一个是 blog 属于某个用户的多对一关系,关系名是 user,最终效果如下图所示,也叫 ER 关系图

爱速搭模型设计

这里的关系并不依赖数据库外键,比如 blog 表里 user_id 可以不是外键。

第 3 步是设计前端界面,爱速搭提供了一个脚手架来快速生成 CRUD 界面,如下图所示

爱速搭脚手架

勾选需要的功能和展示字段后,一个带增删改查的列表页面就生成出来了

爱速搭页面编辑器

这个生成的界面是 amis CRUD 组件的配置,因此还可以进一步调整,比如修改列的展示类型,新增列等。

这样一个基于数据库的展示和编辑界面就做好了,全程无需写代码。

实现这个功能除了前端 UI 组件、后端 SQL 查询外,还有个重要部分是前后端如何交互?

爱速搭之前的做法是后端提供 RESTful 协议的接口,比如默认包含这些 HTTP api

// 创建数据
POST /datamodel

// 查询数据列表
GET /datamodel

// 查询加上过滤条件,下面的示例相当于 name = 'aisuda' AND age < 10
GET /datamodel?name=aisuda&age[lt]=10

// 查询某个数据详情,其中 1 是主键值
GET /datamodel/1

// 修改某个数据,修改内容在提交 payload 里
PUT /datamodel/1

// 删除某个数据
DELETE /datamodel/1

这些接口可以用来实现简单应用,许多低代码平台也就是做到这个程度,但它有很多缺点:

  • 无法控制返回字段,比如文章列表可能只想查询标题而无需返回内容,需要定制特殊参数。

  • 无法进行复杂组合查询,前面的过滤条件都是且(AND),如果要做负责的嵌套条件,比如 name = aisuda OR age < 10) AND deleted IS NOT NULL 就无能为力了,需要定制特殊参数。

  • 无法支持聚合查询,要实现图表类的查询需要再定义新接口。

最开始提到过,数据模型决定了低代码平台能制作应用的上限,如果只有简单的 REST 接口,就只能做简单应用,比起专业开发在灵活性上有很大差距,因此为了让爱速搭能构建更为复杂的应用,我们开发了全新的前后端交互语言 JSONQL。

JSONQL 介绍

JSONQL 其实就是 JSON 格式的自定义 DSL,先来看一个 JSONQL 最简单实例

{
"statement": "select",
"select": ["name"]
"from": "user"
}

它的作用相当于下面这段 SQL

SELECT name FROM user

JSONQL 有什么特点呢:

  • 灵活性强

  • 易用性好

  • 支持多种数据库

  • 性能优

  • 安全性有保障

后面会分别介绍这些功能,从整个系统层面看,JSONQL 是爱速搭前后端交互的中心节点,前端可以通过 REST 接口、SQL、GraphQL 等方式连接后端,这些接口都会先转成 JSONQL,然后再通过 JSONQL 引擎连接各种后端数据。

JSONQL 作为中心节点连接前后端

灵活性强

JSONQL 的第一个特点是灵活性强,它的语法主要参考 SQL,相当于一种以 JSON 格式表达的 SQL,JSONQL 能表达绝大部分 SQL 语句,所以我们还实现了另一种模式,可以支持直接写 SQL,类似下面的写法

{
"sql": "select name from user"
}

它的原理是先进行 SQL 解析,再遍历解析后的 AST 树转成等价的 JSONQL,除了前面的简单例子,它还支持非常复杂的嵌套表达式及子查询,比如下面这段 SQL(来自 SQL for Data Analytics)也可以转换为等价 JSONQL,由于篇幅原因这里就不显示生成的 JSONQL 结构了,内容实在太长,只是为了说明 JSONQL 可以类似 SQL 那样无限嵌套。

SELECT sales_year,
womens_sales - mens_sales as womens_minus_mens,
mens_sales - womens_sales as mens_minus_womens
FROM (
SELECT date_part('year', sales_month) as sales_year,
sum(
case
when kind_of_business = 'Women''s clothing stores' then sales
end
) as womens_sales,
sum(
case
when kind_of_business = 'Men''s clothing stores' then sales
end
) as mens_sales
FROM retail_sales
WHERE kind_of_business in (
'Men''s clothing stores',
'Women''s clothing stores'
)
and sales_month <= '2019-12-01'
GROUP BY 1
) a
ORDER BY 1;

除了查询还有更新和删除,比如类似下面的 SQL 写法

UPDATE table1 SET column1 = column1 + 1

JSONQL 中可以表示为

{
"statement": "update",
"table": "table1",
"setExp": {
"column1": {
"binary": "+",
"left": "column1",
"right": 1
}
}
}

这时你可能会问为什么不直接用 SQL 呢?因为 SQL 是字符串,程序化生成时容易拼接出错,比如要拼接一个 WHERE 条件,你要注意字符串转义(字符串里本身就有引号的情况),生成复杂 WHERE 条件也挺麻烦,比如 WHERE (a=1 or (b=2 and c = 3)) and d=4 ,要注意没有条件时这个 WHERE 字符串也不能输出,而用 JSON 可以方便前端编辑器生成。

接下来你可能会问直接将 SQL 暴露给前端岂不是太危险了?先别急,后面会详细介绍如何保证安全。

现在你对 JSONQL 有了初步认识,可以理解为一种用 JSON 结构化表示的 SQL,绝大部分 SQL 都能表示为 JSONQL 形式,考虑到读者对 SQL 更熟悉,因此后面文章会优先使用 SQL 来作为示例。

易用性好

前面介绍了 JSONQL 基本等于 SQL,所以灵活性强,但除了这点,JSONQL 还提供了许多功能来提升易用性。

支持关联查询及 N+1 问题

第一个是支持关联查询,这使得 JSONQL 既能做到 SQL 的灵活性又能做到 ORM 的易用性。

还是拿前面那个简单的例子

在 user 表里有个一对多关系字段 blogs,也就是一个用户写了多篇博客,查询用户及这个用户写的文章列表可以使用以下 JSONQL

{
"statement": "SELECT",
"select": ["name", "blogs"]
"from": "user"
}

查询输出给前端的结果类似如下示例,注意这里的 blogs 是深层结构,嵌入到每个用户对象里

[{
"id": 1,
"name": "user1",
"blogs": [{
"id": 1,
"title": "title1",
"user_id": 1,
"content": "content1"
}, {
"id": 2,
"title": "title2",
"user_id": 1,
"content": "content2"
}]
}, {
"id": 2,
"name": "user2",
"blogs": [{
"id": 3,
"title": "title3",
"user_id": 2,
"content": "content3"
}]
}]

如何实现呢?有两种做法,一种是 ORM 里常见的 N+1 查询,也就是首先查询 user,然后再根据 user id 去查询 blog,类似如下伪代码

const users = find(User);
for (const user of users) {
const blogs = find(Blog).byUserId(user.id);
}

这种做法下,查到了 10 个用户,就要发起很 10 次 blog 查询,因此也叫 N+1 次查询,如果用户数量很多就会影响数据库性能。

如果要优化 N+1 查询,可以使用就是基于 JOIN 来一次查出,比如下面 SQL 语句

SELECT
`user`.`id`,
`user`.`name`,
`blogs`.`id`,
`blogs`.`title`,
`blogs`.`user_id`,
`blogs`.`content`
FROM
`user`
LEFT JOIN `blog` `blogs` ON `user`.`id` = `blogs`.`user_id`

如果要获取所有数据,这个语句是能满足需求的,但大多数时候还有分页需求,比如只想一次性查询 10 个 user 的数据,这时就没法简单地加上 limit,比如

SELECT
`user`.`id`,
`user`.`name`,
`blogs`.`id`,
`blogs`.`title`,
`blogs`.`user_id`,
`blogs`.`content`
FROM
`user`
LEFT JOIN `blog` `blogs` ON `user`.`id` = `blogs`.`user_id`
LIMIT 10 # 这是错的

为什么这是错的?因为一个用户对应多个博客,合并到一张表里用户就会重复,比如变成如下数据输出

因此假设第一个用户 1 有 10 篇文章,加上 LIMIT 后就只能查出有这一个用户的文章列表了,而我们希望的是查 10 个用户的文章列表。

怎么办呢?有个高级办法是使用 LATERAL 语法,上面的语句可以改成

SELECT "u"."id", "u"."name"
FROM (
SELECT *
FROM "user"
LIMIT 10
) u
LEFT JOIN LATERAL (
SELECT "blog"."id", "blog"."title", "blog"."content"
FROM "blog"
WHERE "u"."id" = "blog"."user_id"
) blogs on true

实际执行类似下面的伪代码:

for user in users:
for blog in blogs:
blog.user_id = user.id

比普通 SQL 查询多了一层嵌套循环,它会在查询完第一层结果后再查询,使得可以支持 LIMIT。

但它的缺点是只有少数几种数据库支持,Postgres 9.3、Oracle 12c 版本以上支持,SQL Server 需要改查 APPLY 语法,支持最差的是 MySQL,要等到 8.0.14 版本才支持。

由于最流行的 MySQL 要高版本才支持,导致在国内实用性不高,因此 JSONQL 默认没有使用这个语法,而是使用了两次查询来解决,方法是:

第一次查询用户 id,使用如下语句

SELECT
DISTINCT `id`
FROM
(SELECT
`user`.`id`,
`user`.`name`,
`blogs`.`id`,
`blogs`.`title`,
`blogs`.`user_id`,
`blogs`.`content`
FROM
`user`
LEFT JOIN `blog` `blogs` ON `user`.`id` = `blogs`.`user_id` ) AS `distinctAlias`
LIMIT 10

接着再基于拿到的用户 id 去查询 blogs,生成类似如下的 SQL 语句

SELECT
`user`.`id`,
`user`.`name`,
`blogs`.`id`,
`blogs`.`title`,
`blogs`.`user_id`,
`blogs`.`content`
FROM
`user`
LEFT JOIN `blog` `blogs` ON `user`.`id` = `blogs`.`user_id`
WHERE
`user`.`id` IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

这样就只需要两个查询语句,也被称为批量查询方案。

然而这个方案并不完美,这里的第二次查询还是没法 LIMIT,如果 blogs 表的数据量很大,比如有的用户有几千篇文章,而只想显示最新一篇,也会导致大量 IO 及带宽浪费,这时使用 N+1 查询反而是个更好的方案。

因此在 JSONQL 中我们增加了一种名为二次查询的语法,使用下面的写法

{
"statement": "SELECT",
"select": [
{
"column": "name"
}
],
"from": "user",
"limit": 10,
"secondQuery": [
{
"select": [
{
"column": "title"
}
],
"limit": 2,
"where": {
"tag": "tag1"
}
"from": "blogs"
}
]
}

在实现的时候,会先查询 user 表,然后再基于返回的 id 一个去查询 blog 表,并且加上 LIMIT 条件。

除了要发起 N 次查询,这个方案还有个缺陷是子表的过滤条件无法放到主表中,也就是无法查询「写过文章标题为 amis 的作者及文章列表」,因为主查询的过滤条件只能是用户字段里的字段。

由于 LATERAL 的巨大优势,JSONQL 后续版本打算进行优化,对于支持 LATERAL 的数据库优先使用它,这样就能完美支持两种场景,不需要区分两种写法了。

除了前面说到的多对一关系场景,JSONQL 还支持多对多、一对多、一对一关系、反向一对一关系,并且支持无限层级嵌套,比如下面的写法:

{
"statement": "SELECT",
"select": ["books.author.address"]
"from": "bookshop"
}

这其实是四层关系,bookshop 和 books 是多对多,books 和 author 是多对一,author 和 adress 是一对一,生成 SQL 有四个 JOIN,输出结果也是个深层对象,类似

[{
"id": 1,
"name": "书店 1",
"books": [{
"title": "书籍 1",
"id": 1,
"author": {
"name": "作者 1",
"id": 1,
"address": {
"id": 1,
"location": "地址 1"
}
}
}]
}]

同时除了 select,在 where 里也能使用关系字段,比如根据书籍标题查找作者列表,查找同名的书都有谁写过

{
"statement": "select",
"where": {
"books.title": "书籍 1"
}
"from": "author"
}

如果用 SQL 需要自己 join,类似如下写法

SELECT
"author"."id",
"author"."name"
FROM
"author"
LEFT JOIN "book" "books" ON "author"."id" = "books"."author_id"
WHERE
"books"."title" = '书籍 1'

关联除了查询,还有增删改操作,比如新增一个用户的时候,还可以同时提供他的文章信息,类似下面的请求

{
"statement": "INSERT",
"table": "user",
"values": {
"name": "user1",
"books": [
{
"title": "book1"
}
]
}
}

在实现的时候会首先写入 user 表,拿到主键的自增 id,然后再写入 book 表,自动将自增 id 填入 author_id 字段中,是不是比写 SQL 要简单很多?

避免接口数量不断增长

由于 JSONQL 的灵活性,它还可以避免前后端接口数量不断增长。

在专业开发中前后端通常使用 REST 协议进行交互,这时经常会遇到一个问题,那就是有些字段其实前端不需要,比如查询文章列表的接口,有时候我们只需要返回标题列表,有时候又想返回内容摘要,为了节省数据传输量,通常要用两个接口来实现,或者在接口上加个字段选择的功能,虽然项目发展,前后端接口会越来越多,甚至还出现了专门管理这些接口的 API 平台。

而在 JSONQL 方案下,数据查询的后端开发被彻底节省了,要查询什么信息前端自己构建 JSONQL 就行,不需要前后端接口约定,也不需要 API 接口文档及管理,前后端只需要一个接口。

这点和 GraphQL 要解决的问题是类似的,后面我们会单独介绍 GraphQL 和 JSONQL 的区别。

简化分页查询

常见的列表页除了查询数据,还需要返回页面总数,需要使用两个查询来实现

SELECT * FROM table1 WHERE A = 1 ORDER BY name LIMIT 10 OFFSET 10
SELECT COUNT(*) FROM table1 WHERE A = 1

JSONQL 简化了这个功能,只需要加上一个 count 配置就能实现类似功能

{
"statement": "SELECT",
"select": ["name"],
"from": "user",
"count": true,
"limit": 10
}

系统会自动去掉 LIMIT/OFFSET 及 ORDER BY 进行 COUNT 查询。

另外除了 LIMIT/OFFSET 及 ORDER BY,如果有 LEFT JOIN 语句且对应的表是对一关系,这个 LEFT JOIN 对 COUNT 结果不会有影响,也可以直接去掉。

避免常见问题及更友好的错误提示

不同数据库对 SQL 的支持不同,还会有奇怪问题,比如下面这个看起来毫无问题的 SQL

SELECT id, date FROM table1;

在 Oracle 下会报两个错误:

  • ORA-00936: missing expression

  • ORA-00933: SQL command not properly ended

你能看出是哪两个问题么?

这两个问题是:

  • date 是保留关键字必须加引号,不然就当成日期字面常量语法了 date '2022-1-1',这也是为什么报缺少表达式。

  • 结尾不能加分号,这是 Oracle 特有问题,不靠搜索引擎恐怕完全想不到。

在 JSONQL 中会自动解决,因为标识符会自动加引号,且在执行时自动去掉最后的分号。

另一方面是针对一些错误的写法,比如 GROUP BY 的字段不在 SELECT 中

SELECT id FROM table1 GROUP BY type

JSONQL 内部解析的时候就会自动加上,变成

SELECT id, type FROM table1 GROUP BY type

对于无法自动修复的场景会提供友好的报错信息,这样做的好处是报错信息是统一的,比起每个数据库千奇百怪的报错信息,JSONQL 给出的报错更易懂。

同时支持 CRUD 及 BI 场景

在绝大部分低代码平台中,CRUD 和 BI 往往是分开处理的,因为它们的前后端使用不同接口,毕竟 BI 查询的 SQL 往往特别复杂,很难使用 REST 形式表示。

但 JSONQL 可以完整支持大部分 SQL,因此在爱速搭里,CRUD 和 BI 的支持全都是用 JSONQL,避免了两次后端开发,简化了开发成本。

支持多种数据库

前面提到了 JSONQL 在易用性方面做的工作,接下来介绍在数据库支持方面的实现。

关系数据库数据库方言

虽然 SQL 有标准,但每个数据库的实现都不太一样,而且反而是国内最流行的 MySQL 其实最不符合标准,JSONQL 要支持大量数据库就得抹平这些方言差异,主要是这两个问题:

  • LIMIT/OFFSET 的写法不一致

  • 函数功能不一致

首先是 LIMIT/OFFSET 写法,这是最为突出的兼容性问题,一方面它非常重要,需要依赖它来做分页查询,另一方面它也是方言问题重灾区,许多数据库都发明了自己的写法,我知道的就有 15 种写法,有些数据库的写法特别繁琐,比如 SQL SERVER 2008。

这个问题的根本原因是标准化太晚了,SQL 在 1974 年就有了,而 LIMIT/OFFSET 写法要等到 2011 年通过的 SQL:2008 规范才正式确定,这中间隔了 34 年,以至于标准的写法没几个数据库支持,反倒是 MySQL 简洁的 LIMIT OFFSET 写法最为流行。

另一个差异点是函数支持,这里最为突出的是日期函数的支持,日期函数是 BI 查询的核心功能,比如想要按年聚合数据,查询每年的销售额,需要用到如下写法

SELECT SUM(price), YEAR(date) from sales GROUP BY YEAR(date)

然而并不是所有数据库都支持 YEAR 函数,对应的替代函数可能有以下这些:

  • toYear(date)

  • DATE_FORMAT_STR(date, 'YYYY')

  • TIME_EXTRACT(date, 'year')

  • to_number(to_char(date, 'yyyy'))

  • CAST(strftime('%Y', date) AS INTEGER)

  • DATEPART(year, date)

如果要输出更一致的效果,比如有些数据库输出月份前面不会补零,如果想保持输出结果一致,比如类似下面的提取年月的函数

DATE_FORMAT(date, '%Y-%m')

需要转成

CAST(DATEPART(year, date) as varchar) + '-' + CAST(RIGHT('0' + CAST(DATEPART(month, date) as varchar), 2) as varchar)

如果是要输出年月日时分秒的话。。

要将所有不兼容函数都替换成方言版本是个非常费时的工作,需要查阅数据库官方文档里的 SQL 语法,而且同一个数据库的不同版本还不一样,为了保证结果正确,还得搭建这些数据库的测试环境来实际测试。我们另一款 BI 产品 Sugar BI 也是花费了大量精力解决这个日期兼容性问题。

除了前面提到这两个,还有很多细节方言问题,比如字段类型、如何获取写入后的自增 id 等。

目前 JSONQL 底层引擎基于 JDBC 实现,可以连接市面上绝大部分数据库,目前已经支持 40+ 数据库,覆盖几乎所有国内开发者可能会使用的数据库,还包括大数据库相关的 Hive、Impala、Doris 等。

对非关系型数据库的支持

除了关系数据库,JSONQL 引擎还实现了对非关系型数据库的支持,使得 JSONQL 还可以对 MongoDB、Elasticsearch、Redis、HBase 及 OData 协议的数据库进行增删改查操作,这里分别介绍一下它们的实现原理。

MongoDB

MongoDB 的实现原理是转成了官方 Client 里的操作,比如 select 对应的是 projection,where 对应的是 filter。

简单增删改查比较容易实现,最复杂的是聚合查询问题,比如 group by,它的原理是转成 Aggregation Pipeline 语句。

比如下面这个 SQL 语句

SELECT SUM(transaction_count) AS sumResult
FROM transactions
GROUP BY account_id
ORDER BY sumResult DESC
LIMIT 2

会转换先转成包含 pipeline 及 aggregate 的 json,然后再去执行 pipeline

{
"pipeline": [{
"$group": {
"sumResult": {
"$sum": "$transaction_count"
},
"_id": {
"account_id": "$account_id"
}
}
}, {
"$project": {
"sumResult": "$sumResult",
"_id": 0
}
}, {
"$sort": {
"sumResult": -1
}
}, {
"$limit": 2
}],
"aggregate": "transactions"
}

你可能知道 MongoDB 官方有个 Connector for BI 工具可以实现类似功能,支持使用 SQL 查询 MongoDB,我们没有使用它,主要原因是:

  • Connector for BI 是属于企业版本 MongoDB 的一部分,开源版本只能试用。

  • Connector for BI 不支持增删改操作,而我们需要完整支持 CUD 功能。

Elasticsearch

接下来是 Elasticsearch,它的原理是转成 REST 接口,比如查询转成 _search 接口,文档增删改转成 _doc 接口。

比如类似下面的 SQL 查询

SELECT a FROM table1 WHERE b = 1 and c > 2 ORDER BY d DESC LIMIT 3

会转成以下 JSON,然后调用 /table1/_search 进行查询

{
"query": {
"bool": {
"must": [{
"term": {
"b": 1
}
}, {
"range": {
"c": {
"gt": 2
}
}
}]
}
},
"sort": [{
"d": {
"order": "desc"
}
}],
"fields": [{
"field": "a"
}],
"size": 3
}

和 MongoDB 类似 Elasticsearch 官方也提供了 SQL 协议对接,我们没有使用它,主要原因是:

  • SQL 接口不支持查询 _id 字段,这对于实现增删改功能是必须的,不然就得要求数据里必须有个字段是唯一标识,导致了使用场景受限,比如在日志类场景下一般不会有唯一标识。

  • SQL 接口是从 6.3 版本开始支持的,低版本无法使用,而 REST 接口可以支持到最早的 Elasticsearch 0.9 版本。

Redis

Redis 主要自由 kv 查询,所以支持它其实就是约定返回结果类型,比如下面的语句

SELECT * FROM key1

对应的是 GET key1 命令,但根据值类型的不同会返回不同数据结构,比如简单字符串返回结果是

如果这个 key1 是个 list 结构,返回结果就将会是

同时 list 支持范围查询,类似如下的 SQL

SELECT * FROM key1 WHERE range BETWEEN 0 AND 2

对应的就是 LRANGE key 0 2 命令

其它还有 set、zset、hash 等类型也有特殊支持,就不展开了。

OData

OData 是一种基于 REST 规范的数据查询协议,背后主要是微软在支持,国内还没见过有人用,它最大好处是提供了一种通用的数据对接方式,如果你自研了数据存储,就可以通过这个接口将数据查询功能暴露出来给其它服务使用。

因为本质是 REST 所以支持其实比较简单,就是转成了对应的 REST 接口,类似 Elasticsearch 的实现。

比如下面这个简单的查询

SELECT FirstName, LastName
FROM People
WHERE FirstName = 'Vincent'
ORDER BY Concurrency DESC
LIMIT 2 OFFSET 0

会转成以下 Query(这里为了方便查看进行了 URL 解码,实际发送是编码后的)

/People?$skip=0&$top=2
&$filter=FirstName eq 'Vincent'
&$orderby=Concurrency desc
&$select=FirstName, LastName

性能优

接下来介绍 JSONQL 里的性能优化,JSONQL 引擎针对许多慢查询场景提供了优化方案。

大 offset 优化

如果 offset 比较大会影响数据库性能,比如下面的 SQL

SELECT film_id, description
FROM film
ORDER BY title
LIMIT 10 OFFSET 10000

数据库在执行的时候要首先读取一万条数据,然后再往后找 10 个,浪费了大量 IO 操作,因此 offset 越大性能越差。

针对 offset 较大的查询,JSONQL 引擎会进行 SQL 改写,改成如下写法

SELECT film_id, description
FROM film
INNER JOIN (
SELECT film_id
FROM film
ORDER BY title
LIMIT 10 OFFSET 10000
) AS OFFSETOPT USING(film_id)

这个语句能实现同样功能,但它的性能更好,原因是在 OFFSET 查询的时候只选择了 film_id 字段,这是个主键字段,因此只需要遍历主键索引而无需读取数据,在获取了主键 id 后再去读取这些主键对应的数据,从而大幅减少了数据读取量。

尽可能使用参数变量

JSONQL 在执行的时候,会将变量都变成参数,比如类似如下语句

SELECT * FROM table1 WHERE a = 'b' LIMIT 10 OFFSET 0

实际执行的时候会自动变成

SELECT * FROM table1 WHERE a = ? LIMIT ? OFFSET ?

然后通过参数绑定的方式来执行,这样做的好处有 2 方面的好处:

  • 避免 SQL 注入问题

  • 优化性能

为什么可以优化性能?因为上面的 3 个参数不管怎么变化都是同一条 SQL 语句,数据库在实现时就能直接复用这个解析及查询计划,无需再计算一次,在 Oracle、SQL Server 等数据库下会有更好性能。

不过参数变量的数量在有些数据库下有限制,比如 SQL Server 只支持 2100 个参数,超过之后就必须改成内嵌。

高级缓存机制

缓存是解决任何性能问题的终极办法,因此 JSONQL 在语法上增加了缓存配置,可以控制过期时间。

除了过期时间,JSONQL 引擎还支持失效过期机制,在数据有变更的时候自动将相关缓存清空。

如何实现呢?最简单的是将相关表的所有缓存都删了,这是最简单的做法,也是 MySQL 之前 Query Cache 的做法,但这种做法缓存命中率太低,所以在 MySQL 8 之后直接去掉了这个功能。

能不能更进一步优化呢?JSONQL 引擎由于有数据模型信息,知道主键是什么,因此对数据有更深入理解,可以实现更精确删除缓存,具体分两种情况:

  • 增删改查场景

  • BI 聚合查询场景

其中 BI 聚合查询场景比较复杂,想要精确知道某个数据修改是否会影响到缓存非常困难,因此无法实现。

增删改查场景就相对容易了,因为模型中有主键,所以查询到的数据后,引擎可以根据主键确认这个缓存都会设计那些行数据库,如果对应的数据有增删改操作,就能将对应行的缓存删除,避免了表级别粒度的命中率太低问题。

但这要求所有数据变更都用 JSONQL,如果有应用不使用 JSONQL 就无法感知了,只能通过自动分析 binlog 来感知数据库变更。

如果你觉得缓存有风险,JSONQL 还可以单独针对 count 加缓存。

对于一个常见的列表页,我们除了查询前十条记录,还需要查询总数,也就是类似如下的语句

SELECT count(*) FROM table1

在 MySQL InnoDB 下,这个 count 查询的性能会随着表中数据量的变多越来越慢,几百万行数据后可能就变成秒级别了,因此这个语句对系统负担很重。

而有时候我们并不需要绝对准确的数据,毕竟一千万零一和一千万零二区别不大,因此可以单独针对 count 开启缓存,这样既能保证性能,又不担心数据缓存过期问题。

乐观锁

出于性能和安全考虑,JSONQL 下不提供排它锁支持,无法使用 SELECT FOR UPDATE 语句。

但提供了另一种被称为乐观锁的机制,可以设置模型的某个字段为版本号,更新的时候会将这个版本号加入条件,比如

UPDATE user SET name = 'jsonql', version = version + 1 WHERE id = 1 and version = 1
减少行锁冲突

有时候我们可能需要频繁更新某个数据,比如

UPDATE pageview set views = views + 1 where page_id = 1

在高并发场景下,比如某个页面的 QPS 很高会导致严重锁冲突,针对这个问题有个小技巧是增加一个随机字段,将一行锁变成多行锁,比如改成如下语句

INSERT INTO pageview (page_id, random, views)
VALUES (1, FLOOR(RAND() * 100), 1) ON DUPLICATE KEY
UPDATE views = views + 1;

这样做就将锁冲突概率减小了 100 倍,但要实现这个功能比较麻烦,ON DUPLICATE KEY 语句 和 RAND 都有数据库方言问题,因此 JSONQL 引擎内置了这种特殊情况的支持,只要配置后就自动支持。

支持分库分表

当数据量增长到亿级别时,单个数据库将很难支持,这时除了切换为分布式数据库以外,还有许多公司使用分库分表来支持,但分库分表会影响查询,导致业务代码复杂。

JSONQL 底层引擎通过引入 ShardingSphere 项目支持了自动分表分库,在实际使用时可以当成一个表来使用。

性能统计和控制

JSONQL 引擎内置了性能统计功能,会统计语句执行性能,提供性能报告方便分析。

同时为了防止慢查询影响性能,还可以临时对某语句进行控制,比如

  • 强制开启缓存,减低对数据库影响

  • 禁用这个语句

  • 金额类型特殊优化

由于浮点数是不精确的,针对金额字段通常需要使用定点数,但定点数没有硬件加速支持,在数据库中都是软件实现的,效率不高。

JSONQL 的增加了一种数据类型,就叫金额,它实际使用整数存储,读出来后自动转成小数,整数计算的性能远比定点数高,所以能提升性能。

这个方案的缺点是在除法运算时会丢失精度,因为有可能算出的结果小于分,比如定点数即便小数位是 2,在计算除法时输出结果还会变成 3 位,比如,比如 0.01/2 变成 0.005,但如果是使用分作为单位,就变成 0 了,所以如果数据量不太推荐用定点数。

随机值返回的优化

有时我们想随机从数据库里取个值

SELECT * FROM table1 ORDER BY rand() LIMIT 1

这个语句在执行的时候需要将所有数据都进行一次排序,再取第一条,数据量较大的时候还会使用临时表或文件存储,对系统性能影响较大。

针对这种情况设计了个特殊语法,实现的时候先 count 总数,然后算个随机数 xxx,再通过下面语句来获取

SELECT * FROM table1 LIMIT xxx, 1

但这个优化必须没有 WHERE 条件,限制比较大。

安全性有保障

前面介绍了 JSONQL 在灵活性、易用性及性能方面的功能,可以看到它拥有和 ORM 一样的易用性,同时又能做到和手写 SQL 一样的灵活性,因此 JSONQL 可以覆盖大部数据增删改查的后端开发工作,彻底降低这部分的研发成本。

但还有一个重要的问题没解决,那就是如何保证安全?JSONQL 相当于将 SQL 能力赋予了前端,但也意味着 SQL 注入及越权访问的风险,如果这个问题不解决,这个方案就彻底没法用,前面提到的所有功能都是浮云。

JSONQL 如何解决呢?主要有以下几点:

  • 后台 JSONQL 模式

  • 数据模型控制

  • 支持数据验证

  • 禁止原始 SQL

  • 限制部分语句

  • 细粒度权限控制

  • 避免误更新或误删

  • 数据脱敏

  • 防止 DDOS

接下来分别介绍这些功能

后台 JSONQL 模式

对于安全性要求较高的场景,JSONQL 可以不直接暴露给前端,而是只在后台使用,这就彻底杜绝了用户任意构造 JSONQL 进行查询的能力,因此安全性和专业开发是一样的。

这种模式叫后台 JSONQL 模式,JSONQL 不是前端动态生成,而是预先编写好的,比如在页面开发的时生成 JSONQL,实际查询的时前端只传递变量,比如在开发时存储了下面这段 JSONQL

{
"statement": "select",
"select": ["name"],
"from": "table1",
"where": {
"name": "${name}"
}
}

这段 JSONQL 等价于

SELECT name FROM table1 WHERE name = ${name}

前端实际发起请求的时候是类似下面的 api 地址

GET /api?name=amis

这和专业开发是一样的,前端只能传递部分变量,而后端 JSONQL 引擎会根据这个变量做替换,如果前端不传递这个变量,比如请求

GET /api

引擎会自动去掉过滤条件,将查询变成

SELECT name FROM table1

如果想支持默认值该怎么办呢?可以这样写

{
"statement": "select",
"select": ["name"],
"from": "table1",
"where": {
"name": "${name || 'amis'}"
}
}

其实 ${} 里是个表达式引擎,这里是实现了和 amis 一样的表达式引擎,原理是基于 antlr4 实现的语法树遍历解释器,语法主要参考 JavaScript,可以支持四则运算、函数嵌套调用等功能,为什么不直接用 JavaScript 语法?因为嵌入 JavaScript 引擎性能较差且有安全性问题。

同时为了提升这个模式下的灵活性,在 JSONQL 语法中还增加了一种动态结构语法,比如下面的语句

{
"statement": "select",
"select": [
{
"if": [
{
"test": "a == 1",
"body": {
"column": "xx",
"func": "DATE_FORMAT",
"arg": "YYYY-MM"
}
},
{
"test": "a == 2",
"body": {
"column": "yy"
}
}
]
}
],
"from": "xxx"
}

当前端传递 GET /api?a=1 的时候,上面 JSONQL 会转成如下格式

{
"statement": "SELECT",
"select": [
{
"column": "xx",
"func": "DATE_FORMAT",
"arg": "YYYY-MM"
}
],
"from": "xxx"
}

等价的 SQL 是

SELECT DATE_FORMAT(xx, 'YYYY-MM') from xxx

如果前端传递 GET /api?a=2,上面 JSONQL 就会变成

{
"statement": "select",
"select": [{
"column": "yy"
}],
"from": "xxx"
}

可以看到我们不仅能改变某个值,还能改变语句本身,从而实现复杂场景的支持。

除了 if 语句外,还有 choose 语句实现循环功能,熟悉 MyBatis 的读者可能会发现和 MyBatis 非常像,这里确实是参考了 MyBatis 的命名,也使得 JSONQL 拥有不亚于 MyBatis 的灵活性。

后台 JSONQL 模式在安全性上和专业开发一致,除此之外 JSONQL 引擎还提供了大量机制来保证安全性,使得可以让前端直接发起动态 JSONQL 调用,下面会分别介绍这些机制。

数据模型控制

在前面的所有例子中,虽然看起来是查询某个表,但在 JSONQL 实际使用时,其实查询的是预先定义的数据模型,比如最开始举的例子

以其中的左侧模型 user 为例,它的模型定义类似如下 JSON 表示(省略了很多,比如关联关系定义)

{
"key": "user",
"name": "用户",
"fields": [{
"type": "int",
"key": "id",
"name": "id",
"isPrimaryKey": true,
"isGenerated": true
}, {
"type": "text",
"key": "name",
"name": "名称"
}, {
"type": "int",
"key": "age",
"name": "年龄"
}]
}

其中最顶层的 key 名相当于表名,而 fields 里的字段相当于列。

在进行 JSONQL 查询的时候,比如下面这个语句

SELECT * FROM user

执行的时候会展开为

SELECT id, name, age FROM user

即便原始数据库的 user 表有很多其它字段,只要这个字段没在模型定义里就没法查,如果遇到类似下面的 SQL 会直接报错,因为在模型定义里没有这个字段

SELECT password FROM user

同样也没法查询没定义的模型,比如

SELECT * FROM information_schema.tables

因为我们的模型定义里没有 information_schema.tables 这个模型,所以上面的查询会报模型不存在。

有了数据模型这一层限制,就可以有效避免查询任意表及字段,限制了查询范围。

禁止原始 SQL

JSONQL 可以表达大部分 SQL 语法,因此 JSONQL 无需支持原始 SQL 形式,所有 SQL 都需要先转成 JSONQL 结构才能使用。

举个例子,SQL 语句在很多地方都支持表达式,比如在字段上使用,类似下面的语句中的第二行表达式

SELECT customer_id
,max(case when fruit = 'apple' and quantity > 5 then 1 else 0 end) as loves_apples
FROM order
GROUP BY 1

要用结构化的代码构造第二行表达式写起来比较复杂,很多 ORM 框架及查询 DSL 会在这里支持写原始 SQL,框架不对这个字符串进行二次处理,在生成 SQL 的时候直接原样输出。

原始 SQL 的问题是对框架来说是个黑盒,完全不知道里面用到了什么函数,查询了哪些字段,而 JSONQL 中的实现是先转成结构化数据,前面的 SQL 可以用如下 JSONQL 表示

{
"select": [{
"column": "customer_id"
}, {
"exp": "max(case when fruit = 'apple' and quantity > 5 then 1 else 0 end)",
"as": "loves_apples"
}],
"statement": "SELECT",
"from": "order",
"groupBy": [{
"val": 1
}]
}

看上去想使用了原始 SQL,但实际不是,引擎底层实现的时会先转换为如下 JSONQL

{
"select": [{
"column": "customer_id"
}, {
"args": [{
"cases": [{
"then": 1,
"when": {
"children": [{
"op": "=",
"left": {
"column": "fruit"
},
"right": "apple"
}, {
"op": ">",
"left": {
"column": "quantity"
},
"right": 5
}],
"operator": "and"
}
}],
"else": 0
}],
"func": "max",
"as": "loves_apples"
}],
"statement": "SELECT",
"from": "order",
"groupBy": [{
"val": 1
}]
}

可以看到所有 SQL 都进行了结构化处理,这使得 JSONQL 引擎可以准确分析出用到了那些语句、函数、查询了哪些字段,配合后面提到的权限控制机制,就能做到即便 JSONQL 完全暴露给前端使用,恶意用户无论怎么构造 JSONQL 也无法越过权限控制。

由于 SQL 的灵活性,实际实现时这里有很多细节问题需要处理,比如类似下面的写法

SELECT u.id FROM table1 u

第一个 u.id 在 SQL 解析的时候时没法区分 u 到底是表名还是别名,因此需要在解析后二次处理,通过上下文确认实际表名是什么,类似情况还有在子查询、JOIN、WITH 语句里也能写别名。

你可能会说别名很少用,直接不支持就好了,但有些场景下必须用别名,比如 FROM 语句使用子查询时必须有别名,还有涉及到多个相同表进行 JOIN 的时候必须有别名来区分,比如有个 staff 员工表,其中有个字段是这个员工的上级 leader_id,它指向另一行 staff 表里数据,如果要同时查询员工及其上级,就需要对这个 staff 表进行 JOIN,这时必须用别名来区分,比如

SELECT staff.name, leader.name
FROM staff
JOIN staff leader ON staff.leader_id = leader.id

禁止 SQL 的主要缺点是导致 JSONQL 对数据库扩展语法支持不好,比如 Postgres 的列支持数组类型,可以使用下面语法查询二维数组里的值

SELECT schedule[1:2][1:1] FROM sal_emp;

目前 JSONQL 还没有对应的结构表示导致无法使用,需要后续扩展,但扩展起来倒是比较容易。

不支持部分语句

虽然 JSONQL 支持大部分 SQL 语句转换,但有些功能不方便权限检查以及有严重安全问题,所以就直接不支持了,比如

  • DDL 相关的语句,这个没必要暴露给前端

  • 数据库管理语句,比如授权的 GRANT 语句等

  • 创建存储过程

另外就是针对表名和字段名都默认做了限制,比如不允许出现分号、引号等特殊字符,也避免了引擎内部实现遗漏时导致 SQL 注入问题。

细粒度权限控制

前面提到 JSONQL 中没有原始 SQL,使得底层引擎可以准确分析出查询了哪些字段,这使得 JSONQL 能够实现细粒度权限控制,目前支持以下 4 种粒度的权限控制

  • 表级别,如果没有某个表权限,那也没法 join,这种情况直接报错返回无权限。

  • 行级别权限,自动在对应的表上加行过滤条件 。

  • 列级别权限,在查询字段的时候自动删掉无权限的字段,如果这个字段是 join 所需的就只有 join on 上保留。

  • 表达式权限,比如对一个字段取 MAX,同时设置 GROUP BY 的值,在这个权限设置下就只能查询这个字段的总数,无法查看明细,比如能查询公司总收入,但无法知道每个部门的收入分别是多少。
    权限相关的配置采用类似如下格式

{
//表名
"table1": {
// 表级别的写入权限
"canInsert": true,
// 列权限配置
"field": {
// 代表所有字段
"*": ["select", "update"],
// 针对某个字段
"fild1": ["select", "update"]
},
// 表达式权限,这里就只有读,因此是个数组
"fieldSelectExp": ["SUM(fieldKey)"],
// 行级别权限配置,针对查、改、删分别配置
"filters": {
"select": {},
"update": {},
"delete": {}
}
}
}

列级别的配置使得你无法选择自己没权限的列,如果使用下面的 JSONQL 进行查询时

{
"statement": "SELECT",
"select": ["*"]
"from": "user"
}

最终生成 SQL 语句会数据模型配置及权限进行展开,变成

SELECT name, age FROM user

也就是只选择模型中已经定义的列及自己有权限的列,如果主动查询没权限的字段会报权限不足。

这里还能做些有意思的扩展,比如下面的写法

{
"statement": "SELECT",
"select": ["*", "!content"]
"from": "blog"
}

意思是查询除了 content 之外的所有列,这样后续增加其它字段就自动支持了,这是 SQL 语句无法实现的功能。

不过也不是所有情况都能展开,如果 FROM 是子查询就不可以

SELECT * FROM (SELECT name FROM user) u

行级别的权限配置是在 filters 下,这里针对查、改、删是独立的,常见的场景是我可以查询所有用户的信息,但我只能更新自己的信息,因此使用类似如下语句的 JSONQL 进行更新的时候

UPDATE user set name = 'amis'

实际执行的 SQL 是

UPDATE user set name = 'amis' WHERE user_id = xxx

行级别过滤条件是后添加的,用户无法控制,比如写了个

UPDATE user set name = 'amis' WHERE 1=1 or 2=2

最终执行会变成

UPDATE user set name = 'amis' WHERE (1=1 or 2=2) and user_id = xx

因此 JSONQL 引擎底层的权限控制彻底杜绝了水平越权问题,只要配置好权限,无论前端怎么构造 JSONQL 请求也无法绕过行列级别的控制,保证了 JSONQL 可以直接暴露给前端使用。

另外行级别权限控制还能用来支持另外两个重要功能:软删除和多租户,对软删除的支持就是在删除时只是更新删除时间字段而不是真删除,而在查询时加上时间不为 NULL,比如

SELECT
"author"."id",
"author"."name"
FROM
"author"
WHERE
"author"."deleted_at" IS NULL

而多租户就是在查询时自动加上租户 ID。

避免误批量更新或删除大量数据

前面提到可以通过权限来避免更新和删除自己没权限的内容,如果无法阻止有权限用户的误删问题,比如管理员不小心执行了下面的语句

DELETE FROM user

就直接将所有数据都删了,默认情况下 JSONQL 不允许不带 WHERE 的语句执行,上面的预计无法支持,但这个限制很容易绕过,比如随便加个肯定是真的条件

DELETE FROM user WHERE id > 0

为了解决这个问题,还可以配置 UPDATE 和 DELETE 里必须有主键等于的条件(id = xx),这时引擎会就不允许前面的语句执行,当然下面的语句也是不允许的,主键等于必须在最顶层且和其它条件是 AND 连接

DELETE FROM user WHERE id = xx OR 1 = 1

通过这种模式可以避免误更新和删除大量数据。

但攻击者还是能通过写个循环来遍历所有 id,因此还需要下面的功能。

自动 Hash id

在设计表结构时,我们一般会使用自增 id 来当成主键,但这导致了两个问题:

  • 暴露总数,因为新增一个就知道目前最大值是什么,通过 id 就知道你有多少注册用户数了。

  • 遍历攻击,如果有接口出现越权漏洞,攻击者就能写个简单的循环遍历所有数据。

有个解决办法是不使用自增 id 作为主键,比如使用 UUID,这对基于 LSM 实现的 NewSQL 分布式数据库比较友好,但对于 MySQL 这种单机数据库却会影响写入性能,因为 MySQL InnoDB 是按主键的 B 树来组织数据的,UUID 的随机性导致经常要在已经写入的两个数据间插入新数据,造成随机读写。

另一个办法是使用 Hash id,但这种做法对业务侵入性较强,因为所有使用到 id 的地方都得注意对 id 的编码和解码,开发起来麻烦还容易遗漏。

为了简化开发,JSONQL 引擎内置了对 Hash id 的支持,在模型设计的时可以对主键开启 Hash id 功能,这时主键的输出结果会自动进行 Hash id 编码,而进行查询的时候会进行 Hash id 解码。

但因为 SQL 的复杂性,有时候虽然能判断字段使用到了主键,也不知道输出结果是不是主键,比如

SELECT (id * 0) + 1 FROM table1

虽然查询字段有主键,但实际上输出结果是 1,如果对这个结果也进行 Hash id 编码会有问题,因为攻击者可以通过遍历来构造,比如

SELECT (id * 0) + 1 FROM table1
SELECT (id * 0) + 2 FROM table1
...
SELECT (id * 0) + 100000 FROM table1

这样就能拿到 10 万个 id 值对应的 Hash id,后续就能通过这些 Hash id 来反查真实 id,也被称为彩虹表攻击。

除了上面的例子还有很多其他可能情况,为了避免这个问题,JSONQL 引擎在开启 Hash id 时主键就只允许查询字段,不允许使用四则运算及函数调用等功能。

数据脱敏

和前面 Hash id 类似的功能是数据脱敏,可以针对某些字段设置脱敏,这些字段在输出的时候会进行脱敏处理,比如手机号自动隐藏中间内容。

另外就是有些字段只能写入不能查询,比如密码字段。

性能白名单模式

前面提到权限控制解决了安全性问题,但攻击者依然能够通过构造复杂的语句来拖慢系统性能(也被称为 CC 攻击),主要有以下几方面:

  • 查询太多数据,比如不加 limit 导致变量全表,这个很好解决,可以配置强制最大 limit 数量,如果超过就不允许,同时 offset 过大也会影响性能,也可以配置最大数量。

  • JOIN 太多表,这主要是关联查询导致的,关联查询依赖 JOIN,如果使用深层关联查询就需要 JOIN 许多表,比如一个多对多关系就要 JOIN 两张表。因此需要支持配置 JOIN 数量限制。

  • 使用某些语句导致无法使用索引,比如 SELECT * from table WHERE MONTH (date) = 12 ,这个语句即便 date 字段加了索引也没法使用,因为实际查询是经过某个函数后的值,这和索引中的值并不匹配,因此这样的语句只能遍历全表,除非在创建索引时用的就是 MONTH (date)。

  • ORDER BY 的字段必须要能用上索引,包括字段顺序、排序方向的一致性。

对于数据量较大的表,为了避免性能风险,JSONQL 引擎支持高性能白名单模式,在这个模式下就只能进行最基本的增删改查操作,限制 LIMIT 及 JOIN 数量,所有 WHERE 条件及 ORDER 字段必须使用索引字段且无法使用函数,禁止 GROUB BY 语句,字段函数只支持几种常用的,如果再配合 Nginx 之类的反向代理基于 IP 限制 QPS,就能保证恶意用户无法通过构造复杂 JSONQL 来进行 CC 攻击。

支持数据验证

后端写入和更新数据有时候需要对数据进行验证,JSONQL 支持数据校验,而且使用和 amis 一样的数据校验配置。

这样做的好处是同样的配置可以直接用于前后端,无需像专业开发那样前后端都分别进行配置。

具体配置就不展开了,可以参考 amis 文档,比如对数字的验证是如下配置

{
"validations": {
"isNumeric": true,
"minimum": 10
},
"validationErrors": {
"isNumeric": "请输入数字"
}
}

数据校验机制保证了用户无法通过构造请求的方式来绕过前端验证,保证了数据准确性。

你可能想问的问题

和 GraphQL 的区别是什么?

总结一下主要区别是:

  • GraphQL 的抽象程度更高,它设计理念就是从业务出发,不关心具体实现,JSONQL 相对来说更底层,使用者对 SQL 越了解用得越好,需要关注具体实现,比如针对 N+1 问题提供了两种语法来让用户选择。

  • GraphQL 不自带实现,需要自己实现或使用第三方引擎,这些引擎的实现参差不齐,对数据库的支持不全,JSONQL 还包括了底层引擎实现,支持大量常见数据库。

  • JSONQL 灵活性更强,很多地方可以使用复杂表达式,覆盖绝大部分 SQL 语句。

  • GraphQL 依赖自定义的 DSL 语言,前后端都需要使用 SDK 才能方便生成,JSONQL 本身就是 JSON,无需 SDK。

  • GraphQL 难以表示树形结构,JSONQL 原生支持。

  • GraphQL 对底层数据存储没有要求,JSONQL 只能对接关系型数据库或 MongoDB 等底层引擎支持的数据存储,如果是自定义存储需要使用 OData 协议对接。

因为 JSONQL 的能力可以完全覆盖 GraphQL,所以还能将 GraphQL 转成 JSONQL,比如下面的 GraphQL 查询

{
blog(id: "1000") {
title
user {
name
}
}
}

可以转换成等价的 JSONQL 查询

{
"statement": "select",
"select": ["title", "user.name"]
"from": "blog",
"where": {
"id": "1000"
}
}

但对于复杂的过滤条件,由于 GraphQL 没有规定标准写法导致难以适配,比如想做字段大于的过滤,在 Hasura 里用的是类似 MongoDB 的语法

query {
movies (where: {rating: {_gt: 4}}) {
id
name
}
}

而 Prisma 用的是字段后面加 _gt 约定

query {
movies (where: {rating_gt: 4}) {
id
name
}
}

还有 limit、offset、orderBy 及聚合查询等功能都没有标准写法,导致了 GraphQL 很容易被实现绑定,不具备可移植性。

和直接用 SQL 的区别是什么?

主要有这几方面:

  • 支持关联查询,尤其是多层级关联查询用 SQL 写起来很复杂,参考前面关联查询的介绍。

  • JSONQL 有更严格的安全检查,参考前面的安全性保障,类似行列级别权限的控制都是自动的,无需自己在 SQL 里加条件判断。

  • JSONQL 会改写 SQL 进行性能优化,比如前面提到的大 OFFSET、随机值等场景。

  • JSONQL 是 JSON,前端生成比较容易,而拼接 SQL 字符串容易出错。

和 APIJSON 的区别是什么?

APIJSON 和 JSONQL 解决了类似问题,同样也是基于 JSON 格式,但在细节思路上有许多不同:

  • APIJSON 是自定义的 DSL 语法,有大量自定义语法,比如 key{}@ 代表 EXISTS 子查询,而 JSONQL 尽可能参考 SQL,这时设计上最大的不同,我猜测 APIJSON 最开始只是为了解决简单的 CRUD 问题,语法上没考虑复杂场景,导致后面发现不能满足时就只好不断在语法上打补丁,而 JSONQL 从一开始支持完整 SQL 语法,因此在灵活性方面更强,上手门槛更低。

  • APIJSON 只适合 CRUD 查询,JSONQL 还能支持任意复杂的 BI 查询,比如字段和查询条件上使用表达式,APIJSON 许多地方只支持字符串,比如字段、group by 等,无法实现类似按年 group by 的功能。

  • APIJSON 的关联查询默认实现是 N+1 查询,而 JSONQL 使用批量查询。

  • JSONQL 不仅支持关系数据库,还支持 MongoDB、Redis 等非关系数据库。

  • APIJSON 支持原始 SQL,而 JSONQL 由于完整支持 SQL 所以无需这个功能,这个重要设计区别使得 APIJSON 不能直接暴露给前端,后续也难以支持行列级别细粒度权限控制。

如何实现复杂业务逻辑?

有时候不仅是数据查询,还需要一些特殊的业务逻辑处理,比如需要对数据进行二次加工后再输出,或者再调用另一个 API 获取其它数据再进行聚合,这时单纯使用 JSONQL 就不够了,我们的做法是有另一个服务编排的功能实现,它具备查询数据、发起 API 请求、循环、分支、自定义代码等功能,可以用来实现简单的业务逻辑。

爱速搭里的服务编排功能

但如果是非常复杂的业务逻辑,比如涉及到复杂的算法,我更推荐写代码实现,这方面没有任何可视化工具能简化,我见过的所有方案屏幕利用率都极低,展现凌乱还不如代码看起来简洁。

说了这么多优点,有缺点么?

因为基本上是 SQL 子集,所以缺点是开发时没有强类型保护,只提供了 JSON Schema 检查,没法做到 ORM 框架那样的强类型检查。

但相应的,不需要写代码使得无需编译,可以进行实时查询,特别适合低代码平台。

总结

最后总结一下,本文最开提到了现有方案无法同时满足易用性和灵活性问题,而 JSONQL 则同时做到了这两点:

  • 在易用性方面 JSONQL 基于 JSON 格式,前端可以很方面生成,同时还支持 SQL 解析,使得只需要了解 SQL 就能使用,而且支持关联查询,可以轻松构建出复杂结构的查询。

  • 在灵活性方面 JSONQL 支持深层嵌套,可以覆盖绝大部分 SQL 场景,在灵活性方面等同于写 SQL ,因此能支撑复杂应用的开发。

同时 JSONQL 还能自动优化性能,并解决了安全问题,使得它甚至可以直接给前端使用,相当于将 SQL 能力赋能给了前端,意味着许多时候前端自己就能完成所有开发,避免了接口沟通成本,也节省了增删改查类场景的后端开发。

关于本文
作者:@吴多益
原文:https://zhuanlan.zhihu.com/p/597981519

关于【引擎】相关推荐,欢迎读者自荐投稿,前端早读课等你来。+v:zhgb_f2er

【第2667期】CSS Houdini:用浏览器引擎实现高级CSS效果

【第2566期】阿里低代码引擎LowCodeEngine正式开源!

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存